SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
7.6 KB · 141 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { EventRow } from "@/components/market/event-row";4import { InstrumentTable } from "@/components/market/instrument-table";5import { Empty, Kv, Page, PageHeader, Section, Stat } from "@/components/ui/section";6import { StatusBadge } from "@/components/ui/status-badge";7import { api } from "@/lib/api";8import { formatDateTime } from "@/lib/format";9import type { Breadth, Exchange, ExchangeStatus, InstrumentWithQuote, MarketEvent } from "@/lib/types";1011interface Detail {12  exchange: Exchange;13  status: ExchangeStatus;14  holidays: Array<{ date: string; name: string; kind: string; closeTime: string | null }>;15  instruments_tracked: number;16  instruments_quoted: number;17  breadth: Breadth;18  gainers: InstrumentWithQuote[];19  losers: InstrumentWithQuote[];20  most_active: InstrumentWithQuote[];21  events: MarketEvent[];22}2324export const dynamic = "force-dynamic";2526export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {27  const { id } = await params;28  try {29    const d = await api<Detail>(`/v1/exchanges/${encodeURIComponent(id)}`);30    return { title: `${d.exchange.name} — status, hours, movers`, description: `${d.exchange.name} (${d.exchange.mic ?? d.exchange.id}): session state, local time, holidays, breadth and the largest moves among instruments Market Atlas tracks.`, alternates: { canonical: `/exchanges/${d.exchange.id}` } };31  } catch {32    return { title: "Exchange" };33  }34}3536export default async function ExchangePage({ params }: { params: Promise<{ id: string }> }) {37  const { id } = await params;38  const d = await api<Detail>(`/v1/exchanges/${encodeURIComponent(id)}`);39  const e = d.exchange;40  const sessions = e.sessions;41  const sessionText = sessions.continuous ? "Continuous (24/7)" : sessions.regular.map((s) => `${s.open}–${s.close}`).join(", ");42  return (43    <Page wide>44      <PageHeader45        kicker={46          <span className="flex items-center gap-2">47            <Link href="/exchanges" className="hover:text-ink">48              Exchanges49            </Link>50            <span>·</span>51            <Link href={`/countries/${e.country}`} className="hover:text-ink">52              {e.country}53            </Link>54          </span>55        }56        title={e.name}57        lead={`${e.operator ? `${e.operator} · ` : ""}${e.city ?? ""} · ${e.timezone}${e.website ? "" : ""}`}58        actions={59          <div className="flex items-center gap-2 text-sm">60            <StatusBadge status={d.status.state} />61            <span className="mono text-ink-2">local {d.status.localTime}</span>62            {e.website && (63              <a href={e.website} target="_blank" rel="noopener noreferrer" className="text-xs text-accent hover:underline">64                website ↗65              </a>66            )}67          </div>68        }69      />70      <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-4">71        <Stat label="Session" value={d.status.state.toLowerCase()} sub={d.status.isHoliday ? d.status.holidayName ?? "holiday" : sessionText} />72        <Stat label="Next transition" value={d.status.nextTransition ? d.status.nextTransition.state.toLowerCase() : "—"} sub={d.status.nextTransition ? formatDateTime(d.status.nextTransition.at, { tz: e.timezone }) : sessions.continuous ? "never closes" : ""} />73        <Stat label="Instruments tracked" value={d.instruments_tracked.toLocaleString("en-US")} sub={`${d.instruments_quoted} with a canonical quote`} />74        <Stat label="Breadth" value={d.breadth.instruments ? `${d.breadth.advancers} ▲ ${d.breadth.decliners} ▼` : "—"} sub={d.breadth.instruments ? `median ${d.breadth.median_change_percent?.toFixed(2)}%` : "no quoted names"} tone={d.breadth.advancers > d.breadth.decliners ? "pos" : d.breadth.decliners > d.breadth.advancers ? "neg" : undefined} />75      </div>76      <div className="mt-6 grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1fr_320px]">77        <div>78          <Section title="Largest gains" hint="instruments tracked on this venue">79            <InstrumentTable rows={d.gainers} compact showExchange={false} emptyText="No quoted instruments on this venue yet." />80          </Section>81          <Section title="Largest losses">82            <InstrumentTable rows={d.losers} compact showExchange={false} defaultSort="change" emptyText="No quoted instruments on this venue yet." />83          </Section>84          <Section title="Most active">85            <InstrumentTable rows={d.most_active} compact showExchange={false} defaultSort="volume" emptyText="No volume observed yet." />86          </Section>87          <Section title="Events" hint="market open/close, halts, moves">88            {d.events.length ? (89              <ul className="rounded-md border border-rule bg-surface px-3">90                {d.events.map((ev) => (91                  <EventRow key={ev.id} e={ev} />92                ))}93              </ul>94            ) : (95              <Empty>No events recorded for this venue yet.</Empty>96            )}97          </Section>98        </div>99        <aside className="space-y-6">100          <div className="rounded-md border border-rule bg-surface p-4">101            <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Venue</h3>102            <Kv cols={1} className="mt-2" items={[["MIC", e.mic ?? "—"], ["Operator", e.operator ?? "—"], ["Country", e.country], ["Time zone", e.timezone], ["Currency", e.currency ?? "—"], ["Asset classes", (e.asset_classes ?? []).join(", ").toLowerCase() || "—"]]} />103          </div>104          <div className="rounded-md border border-rule bg-surface p-4">105            <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Trading sessions (local)</h3>106            <Kv107              cols={1}108              className="mt-2"109              items={[110                ...(sessions.pre ? ([["Pre-market", `${sessions.pre.open}–${sessions.pre.close}`]] as Array<[string, string]>) : []),111                ...sessions.regular.map((s, i) => [`Regular${sessions.regular.length > 1 ? ` ${i + 1}` : ""}`, `${s.open}–${s.close}`] as [string, string]),112                ...(sessions.post ? ([["After hours", `${sessions.post.open}–${sessions.post.close}`]] as Array<[string, string]>) : []),113                ...(sessions.continuous ? ([["Schedule", "24 hours, every day"]] as Array<[string, string]>) : []),114                ...(sessions.weekdays ? ([["Trading days", sessions.weekdays.map((d) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d]).join(" ")]] as Array<[string, string]>) : []),115              ]}116            />117          </div>118          <div className="rounded-md border border-rule bg-surface p-4">119            <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Upcoming holidays & early closes</h3>120            {d.holidays.length ? (121              <ul className="mt-2 divide-y divide-rule text-sm">122                {d.holidays.map((h) => (123                  <li key={h.date} className="flex items-baseline justify-between gap-2 py-1.5">124                    <span className="min-w-0 truncate">{h.name}</span>125                    <span className="mono shrink-0 text-xs text-ink-3">126                      {h.date}127                      {h.kind === "EARLY_CLOSE" && h.closeTime ? ` · closes ${h.closeTime}` : ""}128                    </span>129                  </li>130                ))}131              </ul>132            ) : (133              <p className="mt-2 text-xs text-ink-3">No holiday calendar loaded for this venue — weekdays are assumed to trade.</p>134            )}135          </div>136        </aside>137      </div>138    </Page>139  );140}141